Scrapbox の孤立したページを見直す
ツールをプロトタイプした。hata6502.icon
外部プロジェクトリンク記法のparseに失敗してる
Pin留めページから辿れるページ以外を孤立したページとみなしているのかtakker.iconsta.icon garbage collector という名前の由来です。hata6502.icon
単純に link count が0かで判定すると、互いに参照しあってるけど切り離されたページを見つけられない。hata6502.icon
逆に互いに参照しあっているけど孤立したページ群の島を見つけるのも面白いかもtakker.icon
UserScriptでもできそうtakker.icon
たしかに! hata6502.icon が作ったコマンドラインは、使いづらさがある。
こうかな
一筋縄では行かなそうtakker.icon
リンクをたどる処理が重くてUIが固まる
簡単に書き換えられると思ったら、予想以上に時間がかかってしまった
Webworker周りとリンクを正確に計算するアルゴリズム周りで結構バグりまくった
20:30:45 Denoでも動くようにしておいた
code:js
.then(({main}) => main(globalThis.scrapbox?.Project?.name ?? "villagepump"));
code:script.js
export async function main(project) {
const worker = new Worker(
{
type: navigator?.userAgent?.toLowerCase?.()?.includes?.("firefox") ? "classic" : "module",
}
);
worker.addEventListener(
"message",
({ data }) => data.type === "error" ? reject(data) : resolve(data)
);
worker.postMessage(project);
try {
while (true) {
const { type, data } = await listenMessage();
if (type === "result") {
console.info(data);
alert(data);
break;
}
console.log(data);
}
} catch(e) {
console.error(e.data);
} finally {
worker.terminate();
}
}
function getPromiseSettledAnytimes() {
let _resolve;
let _reject;
const waitForSettled = () => new Promise(
(res, rej) => {
_resolve = res;
_reject = rej;
},
);
const resolve = (value) => _resolve?.(value);
const reject = (reason) => _reject?.(reason);
}
code:worker.js
self.addEventListener("message", async ({data}) => {
const project = data;
try {
self.postMessage({type: "result", data: await main(project)});
} catch(e) {
self.postMessage({type: "error", data: {
name: e.name,
stack: e.stack,
message: e.message,
}});
}
});
function log(message) {
self.postMessage({type: "log", data: message});
}
async function main(project) {
log(loading link data...);
const pinnedPages = await getPinnedPages(project);
const linkMap = await getLinks(project);
log(loaded link data.);
元のコードだと順リンクのみをたどっていたが、このUserScriptでは逆リンクもたどるようにしてみた
なるほど、たしかに計算量が膨大になりそうだ。hata6502.icon
元コードでも逆リンクをたどっている?hata6502.icon hata6502.icon逆リンクの解釈がお互いに違うかもしれない。
解釈はあってますtakker.icon
違いはhata6502.iconさんのlinkMapではリンクの方向を区別せずに格納している点
takker.iconのコードではlinkMapとlinkedMapで区別している
……別に区別する必要ないような
ほんとだtakker.icon
変えよう
code:diff
const linkedMap = new Map();
- const linkeds = existsPages.filter(title => linkMap.get(title).includes(link));
- linkedMap.set(link, linkeds);
-}
+linkMap.forEach((links, title) =>
+ links.forEach((link) => {
+ const linkeds = linkedMap.get(link) ?? [];
+ // 重複があっても処理に支障はない
+ })
+);
ちなみに逆リンクの計算無しでも要Web workerですtakker.icon
UIが固まった
code:worker.js
// 逆リンクデータを作る
log(Creating back link data...);
const linkedMap = new Map();
linkMap.forEach((links, title) =>
links.forEach((link) => {
const linkeds = linkedMap.get(link) ?? [];
// 重複があっても処理に支障はない
})
);
log(Created back link data.);
log(Finding garbage pages...);
let queue = new Set(pinnedPages.map(({title}) => title));
const crawled = new Set();
const connectedPages = new Set();
let counter = 0;
while (queue.size !== 0) {
const next = new Set();
for (const title of queue) {
crawled.add(title);
if (linkMap.has(title)) connectedPages.add(title);
for (const link of [...linkMap.get(title) ?? [], ...linkedMap.get(title) ?? []]) {
if (linkMap.has(link)) connectedPages.add(link);
if (!crawled.has(link)) next.add(link);
}
}
log([${counter}]crawled ${queue.size} links. Next: ${next.size} links);
queue = next;
counter++;
}
log(Found ${existsPages.length - connectedPages.size} garbage pages);
// TODO: エスケープ
const userCSS = `garbages
code:index.css
.quick-launch .project-home .title::after {
content: " garbages";
}
.page-list-item:is(
.map((link) => [data-page-title="${link}"],)
.join("\n")}
) {
display: none !important;
}`;
return userCSS;
};
code:worker.js
// ピンされたページが 100 件を超えることは想定しない。
async function getPinnedPages(project) {
const response = await fetch(https://scrapbox.io/api/pages/${project});
const { pages } = await response.json();
return pages.filter(({pin}) => pin > 0);
}
async function getLinks(project) {
let followingId = null;
const linkMap = new Map();
do {
const response = await (!followingId ?
fetch(https://scrapbox.io/api/pages/${project}/search/titles) :
fetch(https://scrapbox.io/api/pages/${project}/search/titles?followingId=${followingId})
)
followingId = response.headers.get('X-Following-Id');
const pages = await response.json();
for (const {title, links} of pages) {
linkMap.set(title, links);
}
} while(followingId);
return linkMap;
}
あれはゴミ箱ではなく尻尾か……
定期的に garbage を集めるのではなく、ページを書いている途中で garbage 判定する?hata6502.icon
いや、これだと Scrapbox ならではの自由なアウトプット環境が失われるかも。
普段どおりページを書くモードと、garbage collect モードは切り替え式のままでよさそう。
井戸端でやってみた
564ページある
「そういやそういうのあったな」と思い出すページが結構あるtakker.icon
一瞬だけ見かけたページとか
ネタページがちょいちょいあって腹筋がやられるsta.icon
https://gyazo.com/a3b2b6178c286afe9bbc9c2c6c43c2d3/raw
https://gyazo.com/292728c84ea78d023c348227ef610108/raw
https://gyazo.com/ecc859597f530c1ac19098f5cdc4e978/raw
画像にあるが今では削除されたページがたくさん存在する?hatori.icon
ラートはhatori.iconが作成した時点ではページが存在しなかったので、それとは知らず改めて作ったことになるのだな 2年ほど経ったのでもう一度動かしてみたいbsahd.icon
1. npm i -g scrapbox-garbage-collector
エクスポートファイルが必要?
だめだ